Jetpack Compose竟能写出如此炫酷的倒计时APP!
(给安卓开发精选加星标)
转自:掘金 fundroid
https://juejin.cn/post/6936918974399512612
# Compose开发者挑战赛二周目
为配合Jetpack Compose beta版的发布,Google官方发起了Compose开发者挑战赛活动,目前已经入二周目 android-dev-challenge-2
Note:对Compose开发者挑战赛及其参加方法有兴趣的朋友可以参考:《Jetpack Compose 开发挑战赛》
图片过大,截图处理
# App实现
1. 画面构成
输入画面(InputScreen) :
通过数字软键盘输入时间,当新输入数字时,所有数字左移;backspace回退最近一次输入时,所有数字右移。类似计算器app的输入和显示逻辑。倒计时画面(CountdownScreen):
显示当前剩余时间并配有动画效果;根据剩余时间的不同,文字格式和大小会做出变化:最后10秒倒计时的文字也有更醒目的缩放动画。
more than 1h | more than 1m & less than 1h | less than 1m |
---|---|---|
state控制页面跳转
页面之间的跳转逻辑:
InputScreen完成输入后,点击底部Next,跳转到CountdownScreen进入倒计时
CountdownScreen点击底部Cancel,返回InputScreen
Compose没有Activity
、Fragment
这样的页面管理单元,所谓的页面只不过是一个全屏的Composable
,通常可以使用state
实现。复杂的页面场景可以借助navigation-compose
enum class Screen {
Input, Countdown
}
@Composable
fun MyApp() {
var timeInSec = 0
Surface(color = MaterialTheme.colors.background) {
var screen by remember { mutableStateOf(Screen.Input) }
Crossfade(targetState = screen) {
when (screen) {
Screen.Input -> InputScreen {
screen = Screen.CountdownScreen
}
Screen.Countdown -> CountdownScreen(timeInSec) {
screen = Screen.Input
}
}
}
}
}
screen
: 使用state保存并监听当前页面的变化,Crossfade
:Crossfade
可以淡入淡出的切换内部布局;内部根据screen切换不同页面。timeInSec
:InputScreen的输入存入timeInSec
,并携带到CountdownScreen
2. 输入画面(InputScreen)
InputScreen包括以下元素:
输入结果:input-value
回退:backspace
软键盘:softkeyboard
底部:next
根据当前的输入结果,画面各元素会发生变化。
当有输入结果时:next显示、backspace激活、input-value高亮;
反之,next隐藏、backspace禁用、input-value低亮
state驱动UI刷新
如果用传统写法会比较啰嗦,需要在影响输入结果的地方设置监听,例如本例中需要分别监听backspace和next。当输入变化时命令式地去修改相关元素,页面复杂度会随着页面元素增多呈指数级增长。
使用Compose则简单得多,我们只需要将输入结果包装成state
并监听,当state变化时,所有Composable
重新执行、更新状态。即使元素增多也不会影响已有代码,复杂度不会增加。
var input by remember {
mutableStateOf(listOf<Int>())
}
val hasCountdownValue = remember(input) {
input.isNotEmpty()
}
mutableStateOf
创建一个可变化的state,通过by
代理进行订阅,当state变化时当前Composable会重新执行。由于Composable会反复执行,使用
remember{}
可以避免由于Composable的执行反复而反复创建state实例。当
remember
的参数变化时,block会重新执行,上面例子中,当input变化时,判断input是否为空并保存在hasCountdownValue
中,供其他Composable参照。
Column() {
...
Row(
Modifier
.fillMaxWidth()
.height(100.dp)
.padding(start = 30.dp, end = 30.dp)
) {
//Input-value
listOf(hou to "h", min to "m", sec to "s").forEach {
DisplayTime(it.first, it.second, hasCountdownValue)
}
//Backspace
Image(
imageVector = Icons.Default.Backspace,
contentDescription = null,
colorFilter = ColorFilter.tint(
Color.Unspecified.copy(
//根据hasCountdownValue显示不同亮度
if (hasCountdownValue) 1.0f else 0.5f
)
)
)
}
...
//根据hasCountdownValue,是否显示next
if (hasCountdownValue) {
Image(
imageVector = Icons.Default.PlayCircle,
contentDescription = null,
colorFilter = ColorFilter.tint(MaterialTheme.colors.primary)
)
}
}
如上,声明UI的同时加入hasCountdownValue
的判断逻辑,然后等待再次刷新就OK,无需像传统写法那样设置监听并命令式地更新UI。
3. 倒计时画面(CountdownScreen)
CountdownScreen主要包括以下元素:
文字部分:显示hour、second、minutes 以及ms 氛围部分:多个不同类型的圆形动画 底部Cancel
使用animation计算倒计时
如何准确地计算倒计时呢?
最初的方案是使用flow
计算倒计时,然后将flow转成state,驱动UI刷新:
private fun interval(sum: Long, step: Long): Flow<Long> = flow {
while (sum > 0) {
delay(step)
sum -= step
emit(sum)
}
}
但是经过测试发现,由于协程切换也有开销,使用delay处理倒计时并不精确。
经过思考决定使用animation处理倒计时
var trigger by remember { mutableStateOf(timeInSec) }
val elapsed by animateIntAsState(
targetValue = trigger * 1000,
animationSpec = tween(timeInSec * 1000, easing = LinearEasing)
)
DisposableEffect(Unit) {
trigger = 0
onDispose { }
}
Compose的动画也是通过state驱动的,
animateIntAsState
定义动画、计算动画估值并转成state。动画由
targetValue
的变化触发启动。animationSpec
用来配置动画类型,例如这里通过tween
配置一个线性的补间动画。duration
设置为timeInSec * 1000
,也就是倒计时时长的ms。DisposableEffect
用来用来在纯函数中执行副作用。如果参数发生变化,block中的逻辑会在每次重绘(Composition)时执行。DisposableEffect(Unit)
由于参数永远不会变化,意味着block只会在第一次上屏时只执行一次。trigger
初始状态为timeInSec(倒计时总时长),然后在第一次上屏时设置为0,targetValue
变化触发了动画:从timeInSec*1000
执行到0
,duration为timeInSec*1000
,动画结束时就是倒计时的结束,而且绝对精确,没有误差。
接下来只需要将elapsed
换算成合适的文字显示就OK了
val (hou, min, sec) = remember(elapsed / 1000) {
val elapsedInSec = elapsed / 1000
val hou = elapsedInSec / 3600
val min = elapsedInSec / 60 - hou * 60
val sec = elapsedInSec % 60
Triple(hou, min, sec)
}
...
字体动态变化
剩余时间的变化,带来文字内容和字体大小不同。这个实现非常简单,只要Composable中设置size
的时候判断剩余时间就好了。
//根据剩余时间设置字体大小
val (size, labelSize) = when {
hou > 0 -> 40.sp to 20.sp
min > 0 -> 80.sp to 30.sp
else -> 150.sp to 50.sp
}
...
Row() {
if (hou > 0) {//当剩余时间不足一小时时,不显示h
DisplayTime(
hou.formatTime(),
"h",
fontSize = size,
labelSize = labelSize
)
}
if (min > 0) {//剩余时间不足1分钟,不显示m
DisplayTime(
min.formatTime(),
"m",
fontSize = size,
labelSize = labelSize
)
}
DisplayTime(
sec.formatTime(),
"s",
fontSize = size,
labelSize = labelSize
)
}
氛围动画
氛围动画对提高App质感很重要,app中使用了如下几种动画烘托氛围:
正圆呼吸灯效果:1次/2秒
半圆环跑马灯效果:1次/1秒
雷达动画:倒计时结束时扫描进度100%
文字缩放:倒计时10秒缩放,1次/1秒
这里使用transition
同步多个动画
val transition = rememberInfiniteTransition()
var trigger by remember { mutableStateOf(0f) }
//线性动画实现雷达动画
val animateTween by animateFloatAsState(
targetValue = trigger,
animationSpec = tween(
durationMillis = durationMills,
easing = LinearEasing
),
)
//infiniteRepeatable+restart实现跑马灯
val animatedRestart by transition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(tween(1000), RepeatMode.Restart)
)
//infiniteRepeatable+reverse实现呼吸灯
val animatedReverse by transition.animateFloat(
initialValue = 1.05f,
targetValue = 0.95f,
animationSpec = infiniteRepeatable(tween(2000), RepeatMode.Reverse)
)
//infiniteRepeatable+reverse实现文字缩放
val animatedFont by transition.animateFloat(
initialValue = 1.5f,
targetValue = 0.8f,
animationSpec = infiniteRepeatable(tween(500), RepeatMode.Reverse)
)
rememberInfiniteTransition
创建了一个repeatable的transition
,transition通过animateXXX
创建多个动画(state),同一个transition创建的动画保持同步。app中创建了3个动画:animatedRestart
、animatedReverse
、animatedFont
transition中也可以设置animationSpec。app中配置的
infiniteRepeatable
是一个repeat动画,可以通过参数设置duration
以及RepeatMode
绘制圆环图形
接下来就可以基于上面创建的动画state绘制各种圆形的氛围了,通过不断地compoition实现动画效果。
Canvas(
modifier = Modifier
.align(Alignment.Center)
.padding(16.dp)
.size(350.dp)
) {
val diameter = size.minDimension
val radius = diameter / 2f
val size = Size(radius * 2, radius * 2)
//跑马灯半圆
drawArc(
color = color,
startAngle = animatedRestart,
sweepAngle = 150f,
size = size,
style = Stroke(15f),
)
//呼吸灯整圆
drawCircle(
color = secondColor,
style = strokeReverse,
radius = radius * animatedReverse
)
//雷达扇形
drawArc(
startAngle = 270f,
sweepAngle = animateTween,
brush = Brush.radialGradient(
radius = radius,
colors = listOf(
purple200.copy(0.3f),
teal200.copy(0.2f),
Color.White.copy(0.3f)
),
),
useCenter = true,
style = Fill,
)
}
Canvas{}
可以绘制自定义图形。drawArc
用来绘制一个带角度的弧形,startAngle
和sweepAngle
设置弧在圆上的 其实位置,这里设置startAngle为animatedRestart
,根据state的变化实现动画效果。style设置为Stroke
表示只绘制边框,设置为Fill
则表示填充这个弧形区域形成扇形。drawCircle
用来绘制一个正圆,这里通过animatedReverse
,改变半径实现呼吸灯效果
Note: 关于Compose动画的更多内容可以参考 《一文学会使用Jetpack Compose Animations》
# 总结
Compose的核心是State驱动UI刷新,animation也是依靠state来实现动画。因此除了服务于视觉效果,animation还可以用来计算state。到这时才恍然大悟组织方在题目描述中提示可能会用到animation,其更主要的目的是用来精确计算countdown的最新状态。
CountdownTimer这样的项目很适合拿来给新技术练手,第二周挑战截止日是3月10日,而且后面还有2个挑战,都是以鼓励新手为主所以难度应该不会很高。如果你还没有接触过Compose,不妨趁这个机会尝尝鲜吧~
项目地址:TikTik
- EOF -
看完本文有收获?请分享给更多人
推荐关注「安卓开发精选」,提升安卓开发技术
点赞和在看就是最大的支持❤️